import {
  assert,
  createContainer,
  createFixture,
  createObserverLocator,
  createScopeForTest,
} from '@aurelia/testing';
import { createConditionalExpression, createAccessScopeExpression, createInterpolation } from '@aurelia/expression-parser';
import {
  BindingMode,
  CustomElement,
  InterpolationBinding,
  SVGAnalyzer,
  IPlatform,
  ValueConverter,
} from '@aurelia/runtime-html';
import { tasksSettled } from '@aurelia/runtime';
import { resolve } from '@aurelia/kernel';

type CaseType = {
  expected: number | string;
  expectedStrictMode?: number | string;
  expectedValueAfterChange?: number | string;
  changeFnc?: (val: any, platform: IPlatform) => any;
  app: any;
  interpolation: string;
  it: string;
  only?: boolean;
};

const testDateString = new Date('Sat Feb 02 2002 00:00:00 GMT+0000 (Coordinated Universal Time)').toString();
const ThreeHoursAheadDateString = new Date('Sat Feb 02 2002 03:00:00 GMT+0000 (Coordinated Universal Time)').toString();
const ThreeDaysDateString = new Date('Sat Feb 03 2002 00:00:00 GMT+0000 (Coordinated Universal Time)').toString();

describe('3-runtime-html/interpolation.spec.ts', function () {
  describe('[UNIT]interpolation', function () {
    const cases: CaseType[] = [
      {
        expected: 'wOOt', expectedStrictMode: 'wOOt', app: class { public value?: string | number = 'wOOt'; public value1?: string | number; }, interpolation: `$\{value}`, it: 'Renders expected text'
      },
      {
        expected: '',
        expectedStrictMode: '',
        app: class { public value = undefined; },
        interpolation: `$\{value}`,
        it: 'Undefined value renders nothing',
      },
      {
        expected: NaN,
        expectedStrictMode: 'NaN',
        app: class { public value1 = undefined; public value = 5; },
        interpolation: `$\{value1 + value}`,
        it: 'Two values one undefined sum correctly',
      },
      {
        expected: NaN,
        expectedStrictMode: 'NaN',
        expectedValueAfterChange: '-4', // ((value || 0) + 1) - value1
        app: class { public value = undefined; public value1 = 5; },
        interpolation: `$\{value - value1}`,
        it: 'Two values one undefined minus correctly',
      },
      {
        expected: '',
        expectedStrictMode: '',
        app: class { isStrictMode = true; public value = null; },
        interpolation: `$\{value}`,
        it: 'Null value renders nothing',
      },
      {
        expected: 5,
        expectedStrictMode: '5',
        app: class { public value1 = null; public value = 5; },
        interpolation: `$\{value1 + value}`,
        it: 'Two values one Null sum correctly',
      },
      {
        expected: -5,
        expectedStrictMode: '-5',
        app: class { public value = null; public value1 = 5; },
        interpolation: `$\{value - value1}`,
        it: 'Two values one Null minus correctly',
      },
      {
        expected: 'NaN',
        expectedStrictMode: 'NaN',
        expectedValueAfterChange: 5,
        app: class { public value = undefined; public value1 = 5; },
        interpolation: `$\{value1/value}`,
        it: 'Number divided by undefined is Infinity',
      },
      {
        expected: 1,
        expectedStrictMode: 1,
        expectedValueAfterChange: 0.8333333333333334,
        app: class { public value = 5; public value1 = 5; },
        interpolation: `$\{value1/value}`,
        it: 'Number divided by number works as planned',
      },
      {
        expected: 'true',
        expectedValueAfterChange: 'false',
        changeFnc: (val) => !val,
        app: class { public value = true; },
        interpolation: `$\{value}`,
        it: 'Boolean prints true',
      },
      {
        expected: 'false',
        expectedValueAfterChange: 'true',
        changeFnc: (val) => !val,
        app: class { public value = false; },
        interpolation: `$\{value}`,
        it: 'Boolean prints false'
      },
      {
        expected: 'false',
        expectedValueAfterChange: 'false',
        changeFnc: (val) => !val,
        app: class { public value = false; },
        interpolation: `$\{value && false}`,
        it: 'Boolean prints false with && no matter what',
      },
      {
        expected: 'test',
        app: class { public value = 'test'; },
        interpolation: `$\{true && value}`,
        it: 'Coalesce works properly'
      },
      {
        expected: testDateString,
        expectedValueAfterChange: ThreeDaysDateString,
        changeFnc: (_val: Date) => {
          return new Date(ThreeDaysDateString);
        }, app: class { public value = new Date('Sat Feb 02 2002 00:00:00 GMT+0000 (Coordinated Universal Time)'); },
        interpolation: `$\{value}`, it: 'Date works and setDate triggers change properly',
      },
      {
        expected: `undefined${testDateString}`,
        expectedStrictMode: `undefined${testDateString}`,
        expectedValueAfterChange: `undefined${ThreeDaysDateString}`,
        changeFnc: (_val: Date) => {
          return new Date(ThreeDaysDateString);
        }, app: class { public value = new Date('Sat Feb 02 2002 00:00:00 GMT+0000 (Coordinated Universal Time)'); },
        interpolation: `$\{undefined + value}`, it: 'Date works with undefined expression and setDate triggers change properly',
      },
      {
        expected: `null${testDateString}`,
        expectedStrictMode: `null${testDateString}`,
        expectedValueAfterChange: `null${ThreeDaysDateString}`,
        changeFnc: (_val: Date) => {
          return new Date(ThreeDaysDateString);
        }, app: class { public value = new Date('Sat Feb 02 2002 00:00:00 GMT+0000 (Coordinated Universal Time)'); },
        interpolation: `$\{null + value}`, it: 'Date works with null expression and setDate triggers change properly',
      },
      {
        expected: testDateString,
        expectedValueAfterChange: ThreeHoursAheadDateString,
        changeFnc: (_val: Date) => {
          return new Date(ThreeHoursAheadDateString);
        }, app: class { public value = new Date('Sat Feb 02 2002 00:00:00 GMT+0000 (Coordinated Universal Time)'); },
        interpolation: `$\{value}`, it: 'Date works and setHours triggers change properly'
      },
      {
        expected: { foo: 'foo', bar: 'bar' }.toString(),
        expectedValueAfterChange: { foo: 'foo', bar: 'bar', wat: 'wat' }.toString(),
        changeFnc: (val) => {
          val.wat = 'wat';
          return val;
        }, app: class { public value = { foo: 'foo', bar: 'bar', wat: 'wat' }; }, interpolation: `$\{value}`, it: 'Object binding works'
      },
      {
        expected: [0, 1, 2].toString(),
        expectedValueAfterChange: [0, 1, 2, 3].toString(),

        changeFnc: (val) => {
          val.push(3);
          return val;  // Array observation no worky
        }, app: class { public value = [0, 1, 2]; },
        interpolation: `$\{value}`,
        it: 'Array prints comma delimited values and observes push correctly'
      },
      {
        expected: [0, 1, 2].toString(),
        expectedValueAfterChange: [0, 1].toString(),

        changeFnc: (val) => {
          val.pop();
          return val;  // Array observation no worky
        }, app: class { public value = [0, 1, 2]; },
        interpolation: `$\{value}`,
        it: 'Array prints comma delimited values and observes pop correctly'
      },

      {
        expected: [0, 1, 2].toString(),
        expectedValueAfterChange: [0, 2].toString(),

        changeFnc: (val) => {
          val.splice(1, 1);
          return val;
        }, app: class { public value = [0, 1, 2]; },
        interpolation: `$\{value}`,
        it: 'Array prints comma delimited values and observes splice correctly'
      },
      {
        expected: [0, 1, 2].toString(),
        expectedValueAfterChange: [5, 6].toString(),

        changeFnc: () => {
          return [5, 6];
        }, app: class { public value = [0, 1, 2]; },
        interpolation: `$\{value}`,
        it: 'Array prints comma delimited values and observes new array correctly'
      },
      {
        expected: 'test foo out bar',
        expectedValueAfterChange: 'test foo woot out bar',
        changeFnc: (val) => {
          return `${val} woot`;
        }, app: class { public value = 'foo'; public value2 = 'bar'; },
        interpolation: `test $\{value} out $\{value2}`,
        it: 'Multiple statements work in interpolation'
      },
      {
        expected: 'test foo out foo',
        expectedValueAfterChange: 'test foo woot out foo woot',
        changeFnc: (val) => {
          return `${val} woot`;
        }, app: class { public value = 'foo'; },
        interpolation: `test $\{value} out $\{value}`,
        it: 'Multiple SAME statements work in interpolation'
      },
      {
        expected: 'test  out ',
        expectedValueAfterChange: 'test foo out foo',
        changeFnc: () => {
          return 'foo';
        }, app: class { public value: any; },
        interpolation: `test $\{value} out $\{value}`,
        it: 'Multiple SAME statements work in interpolation with undefined'
      },
      {
        expected: 'test  out ',
        expectedValueAfterChange: 'test foo-node out ',
        changeFnc: (_, platform) => {
          const span = platform.document.createElement('span');
          span.appendChild(platform.document.createTextNode('foo-node'));
          return span;
        }, app: class { public value: any; },
        interpolation: `test $\{value} out `,
        it: 'works with HTML Element'
      },
      {
        expected: 'test  out ',
        // special edcase, same node is appended in multiple positions
        // resulting in the last place that uses it wins
        expectedValueAfterChange: 'test  out foo-node',
        changeFnc: (_, platform) => {
          return platform.document.createTextNode('foo-node');
        }, app: class { public value: any; },
        interpolation: `test $\{value} out $\{value}`,
        it: 'Multiple SAME statements work in interpolation with HTML Text'
      },
      {
        expected: 'test 1,2,3 out',
        expectedValueAfterChange: 'test foo-node out',
        changeFnc: (_, platform) => {
          return platform.document.createTextNode('foo-node');
        },
        app: class { public value: any = [1, 2, 3]; },
        interpolation: `test $\{value} out`,
        it: 'changes from array to node',
      },
      {
        expected: 'test foo-node out',
        expectedValueAfterChange: 'test 1,2,3 out',
        changeFnc: (_, _platform) => {
          return [1, 2, 3];
        },
        app: class {
          public static get inject() { return [IPlatform]; }
          public value: any;
          public constructor(
            p: IPlatform,
          ) {
            this.value = p.document.createTextNode('foo-node');
          }
        },
        interpolation: `test $\{value} out`,
        it: 'changes from node array'
      }
    ];

    cases.forEach((x) => {
      const $it = x.only ? it.only : it;
      $it(x.it, async function () {
        const { tearDown, appHost } = createFixture(`<template>${x.interpolation}</template>`, x.app);
        assert.strictEqual(appHost.textContent, x.expected.toString(), `host.textContent`);
        await tearDown();
      });
      $it(`${x.it} change tests work`, async function () {
        const { tearDown, appHost, platform, component } = createFixture(`<template>${x.interpolation}</template>`, x.app);
        if (x.changeFnc !== undefined) {
          const val = x.changeFnc(component.value, platform);
          if (val != null) {
            component.value = val;
          }
        } else if (typeof x.expected === 'string' && x.expected !== 'Infinity') {
          component.value = `${component.value || ``}1`;

        } else {
          component.value = (component.value as number || 0) + 1;
        }
        await tasksSettled();
        assert.strictEqual(appHost.textContent, (x.expectedValueAfterChange?.toString()) || (x.expected as number + 1).toString(), `host.textContent`);
        await tearDown();
      });
      if (x.expectedStrictMode) {
        $it(`${x.it} STRICT MODE `, async function () {
          const strict = CustomElement.define({ name: 'strict', template: `${x.interpolation}`, strict: true }, x.app);
          const { tearDown, appHost } = createFixture(`<template><strict></strict></template>`, class { }, [strict], void 0, void 0);
          assert.strictEqual(appHost.textContent, x.expectedStrictMode.toString(), `host.textContent`);
          await tearDown();
        });
      }
    });

    describe('volatile expressions', function () {
      it('handles single', async function () {
        const container = createContainer();
        const observerLocator = createObserverLocator(container);
        const interpolation = createInterpolation(
          ['', ''],
          [createConditionalExpression(
            createAccessScopeExpression('checked'),
            createAccessScopeExpression('yesMsg'),
            createAccessScopeExpression('noMsg'),
          )]
        );
        const target = { value: '' };
        const binding = new InterpolationBinding(
          { state: 0 },
          container,
          observerLocator,
          interpolation,
          target,
          'value',
          BindingMode.toView,
          false,
        );
        const source = { checked: false, yesMsg: 'yes', noMsg: 'no' };

        let handleChangeCallCount = 0;
        let updateTargetCallCount = 0;

        binding.updateTarget = (updateTarget => {
          return function (...args: unknown[]) {
            updateTargetCallCount++;
            return updateTarget.apply(this, args);
          };
        })(binding.updateTarget);
        binding.partBindings[0].handleChange = (handleChange => {
          return function (...args: unknown[]) {
            handleChangeCallCount++;
            return handleChange.apply(this, args);
          };
        })(binding.partBindings[0].handleChange);
        binding.bind(createScopeForTest(source));

        assert.strictEqual(target.value, 'no');
        assert.deepStrictEqual(
          [handleChangeCallCount, updateTargetCallCount],
          [0, 1],
        );

        // inactive branch of conditional shouldn't call handleChange
        source.yesMsg = 'hello';
        await tasksSettled();
        assert.deepStrictEqual(
          [handleChangeCallCount, updateTargetCallCount],
          [0, 1],
        );

        source.noMsg = 'hello';
        await tasksSettled();
        assert.strictEqual(target.value, 'hello');
        assert.deepStrictEqual(
          [handleChangeCallCount, updateTargetCallCount],
          [1, 2],
        );

        source.yesMsg = 'yes';
        source.checked = true;
        await tasksSettled();
        assert.strictEqual(target.value, 'yes');
        assert.deepStrictEqual(
          [handleChangeCallCount, updateTargetCallCount],
          [2, 3],
        );

        source.noMsg = 'no1111';
        await tasksSettled();
        assert.strictEqual(target.value, 'yes');
        assert.deepStrictEqual(
          [handleChangeCallCount, updateTargetCallCount],
          [2, 3],
        );
      });

      it('handles multiple', async function () {
        const container = createContainer();
        const observerLocator = createObserverLocator(container);
        const interpolation = createInterpolation(
          ['', '--', ''],
          [
            createConditionalExpression(
              createAccessScopeExpression('checked1'),
              createAccessScopeExpression('yes1'),
              createAccessScopeExpression('no1'),
            ),
            createConditionalExpression(
              createAccessScopeExpression('checked2'),
              createAccessScopeExpression('yes2'),
              createAccessScopeExpression('no2'),
            )
          ]
        );
        const target = { value: '' };
        const binding = new InterpolationBinding(
          { state: 0 },
          container,
          observerLocator,
          interpolation,
          target,
          'value',
          BindingMode.toView,
          false,
        );
        const source = {
          checked1: false,
          yes1: 'yes1',
          no1: 'no1',
          checked2: false,
          yes2: 'yes2',
          no2: 'no2'
        };

        let handleChange1CallCount = 0;
        let handleChange2CallCount = 0;
        let updateTargetCallCount = 0;

        binding.updateTarget = (updateTarget => {
          return function (...args: unknown[]) {
            updateTargetCallCount++;
            return updateTarget.apply(this, args);
          };
        })(binding.updateTarget);
        binding.partBindings[0].handleChange = (handleChange => {
          return function (...args: unknown[]) {
            handleChange1CallCount++;
            return handleChange.apply(this, args);
          };
        })(binding.partBindings[0].handleChange);
        binding.partBindings[1].handleChange = (handleChange => {
          return function (...args: unknown[]) {
            handleChange2CallCount++;
            return handleChange.apply(this, args);
          };
        })(binding.partBindings[1].handleChange);
        binding.bind(createScopeForTest(source));

        assert.strictEqual(target.value, 'no1--no2');
        assert.deepStrictEqual(
          [handleChange1CallCount, handleChange2CallCount, updateTargetCallCount],
          [0, 0, 1],
        );

        // inactive branch of conditional shouldn't call handleChange
        source.yes2 = 'yes22';
        await tasksSettled();
        assert.strictEqual(target.value, 'no1--no2');
        assert.deepStrictEqual(
          [handleChange1CallCount, handleChange2CallCount, updateTargetCallCount],
          [0, 0, 1],
        );
        source.yes1 = 'yes11';
        await tasksSettled();
        assert.deepStrictEqual(
          [handleChange1CallCount, handleChange2CallCount, updateTargetCallCount],
          [0, 0, 1],
        );

        // reset for next assertion
        source.yes2 = 'yes2';
        source.yes1 = 'yes1';

        source.checked2 = true;
        await tasksSettled();
        assert.strictEqual(target.value, 'no1--yes2');
        assert.deepStrictEqual(
          [handleChange1CallCount, handleChange2CallCount, updateTargetCallCount],
          [0, 1, 2],
        );

        source.checked1 = true;
        await tasksSettled();
        assert.strictEqual(target.value, 'yes1--yes2');
        assert.deepStrictEqual(
          [handleChange1CallCount, handleChange2CallCount, updateTargetCallCount],
          [1, 1, 3],
        );

        source.no1 = source.no2 = 'hello';
        await tasksSettled();
        assert.strictEqual(target.value, 'yes1--yes2');
        assert.deepStrictEqual(
          [handleChange1CallCount, handleChange2CallCount, updateTargetCallCount],
          [1, 1, 3],
        );
      });
    });
  });

  it('works with strict mode', async function () {
    const { assertText, component } = createFixture(
      'hey ${id}',
      CustomElement.define({ name: 'app' }, class { id = undefined; })
    );
    assertText('hey ');

    component.id = '1';
    await tasksSettled();
    assertText('hey 1');

    component.id = null;
    await tasksSettled();
    assertText('hey ');
  });

  it('observes and updates when bound with array', async function () {
    const { tearDown, appHost, ctx } = await createFixture(
      `<label repeat.for="product of products">
      <input
        type="checkbox"
        model.bind="product.id"
        checked.bind="selectedProductIds"></label>
      Selected product IDs: \${selectedProductIds}`,
      class App {
        public products = [
          { id: 0, name: 'Motherboard' },
          { id: 1, name: 'CPU' },
          { id: 2, name: 'Memory' },
        ];

        public selectedProductIds = [];
      }
    ).started;

    assert.includes(appHost.textContent, 'Selected product IDs: ');

    const [box1, box2, _box3] = Array.from(appHost.querySelectorAll('input'));
    box1.checked = true;
    box1.dispatchEvent(new ctx.CustomEvent('change'));
    assert.includes(appHost.textContent, 'Selected product IDs: ');
    await tasksSettled();
    assert.includes(appHost.textContent, 'Selected product IDs: 0');
    box2.checked = true;
    box2.dispatchEvent(new ctx.CustomEvent('change'));
    assert.includes(appHost.textContent, 'Selected product IDs: 0');
    await tasksSettled();
    assert.includes(appHost.textContent, 'Selected product IDs: 0,1');

    await tearDown();
  });

  it('[Repeat] interpolates expression with value converter that returns HTML nodes', async function () {
    const { tearDown, appHost, component } = await createFixture(
      `<template><div repeat.for="item of items">\${item.value | $}</div></template>`,
      class App {
        public items = Array.from({ length: 10 }, (_, idx) => {
          return { value: idx + 1 };
        });
      },
      [
        ValueConverter.define('$', class MoneyValueConverter {
          public static get inject() {
            return [IPlatform];
          }

          public constructor(private readonly p: IPlatform) { }

          public toView(val: string) {
            let num = Number(val);
            num = isNaN(num) ? 0 : num;
            return this.toNode(`<span>$<b>${num}</b></span>`);
          }

          private toNode(html: string) {
            const parser = this.p.document.createElement('div');
            parser.innerHTML = html;
            return parser.firstChild;
          }
        })
      ]
    ).started;

    let divs = Array.from(appHost.querySelectorAll('div'));
    assert.strictEqual(divs.length, 10);

    divs.forEach((div, idx) => {
      assert.strictEqual(div.textContent, `$${idx + 1}`);
      const b = div.querySelector('b');
      assert.strictEqual(b.textContent, String(idx + 1));
    });

    component.items = Array.from({ length: 10 }, (_, idx) => {
      return { value: idx + 11 };
    });

    divs = Array.from(appHost.querySelectorAll('div'));
    assert.strictEqual(divs.length, 10);
    divs.forEach((div, idx) => {
      assert.strictEqual(div.textContent, `$${idx + 11}`);
      const b = div.querySelector('b');
      assert.strictEqual(b.textContent, String(idx + 11));
    });

    divs.forEach((div, idx) => {
      assert.strictEqual(div.textContent, `$${idx + 11}`);
      const b = div.querySelector('b');
      assert.strictEqual(b.textContent, String(idx + 11));
    });
    assert.strictEqual(appHost.textContent, component.items.map(item => `$${item.value}`).join(''));

    component.items = [];
    divs = Array.from(appHost.querySelectorAll('div'));
    assert.strictEqual(divs.length, 0);
    assert.strictEqual(appHost.textContent, '');

    await tearDown();
    assert.strictEqual(appHost.textContent, '');
  });

  it('[IF/Else] interpolates expression with value converter that returns HTML nodes in <template/>', async function () {
    const { stop, appHost, component } = createFixture(
      `<template if.bind="show">\${message | $:'if'}</template><template else>\${message | $:'else'}</template>`,
      class App {
        public show = true;
        public message = 'foo';
      },
      [
        ValueConverter.define('$', class MoneyValueConverter {
          private p = resolve(IPlatform);

          public toView(val: string, prefix: string) {
            return this.toNode(`<${prefix}>${prefix} ${val}</${prefix}>`);
          }

          private toNode(html: string) {
            const parser = this.p.document.createElement('div');
            parser.innerHTML = html;
            return parser.firstChild;
          }
        })
      ]
    );
    assert.strictEqual(appHost.textContent, 'if foo');
    assert.html.innerEqual(appHost, '<if>if foo</if>');

    component.show = false;

    assert.strictEqual(appHost.textContent, 'else foo');
    assert.html.innerEqual(appHost, '<else>else foo</else>');

    void stop();
    // when a <template else/> is removed, it doesn't leave any nodes in the appended target
    // so the app host text content is turned back to empty
    assert.strictEqual(appHost.textContent, '');
    assert.html.innerEqual(appHost, '');
  });

  describe('3-runtime/interpolation.spec.ts -- interpolation -- attributes', function () {
    it('interpolates on value attr of <progress/>', async function () {
      const { component, appHost, tearDown } = await createFixture(
        `<progress value="\${progress}">`,
        class App {
          public progress = 0;
        },
      ).started;

      const progress = appHost.querySelector('progress');
      assert.strictEqual(progress.value, 0);

      component.progress = 1;
      await tasksSettled();
      assert.strictEqual(progress.value, 1);

      await tearDown();
    });

    it('interpolates value attr of <input />', async function () {
      const { component, appHost, tearDown } = await createFixture(
        `<input value="\${progress}">`,
        class App {
          public progress = 0;
        },
      ).started;

      const input = appHost.querySelector('input');
      assert.strictEqual(input.value, '0');

      component.progress = 1;
      assert.strictEqual(input.value, '0');
      await tasksSettled();
      assert.strictEqual(input.value, '1');

      await tearDown();
    });

    it('interpolates value attr of <textarea />', async function () {
      const { component, appHost, tearDown } = await createFixture(
        `<textarea value="\${progress}">`,
        class App {
          public progress = 0;
        },
      ).started;

      const textArea = appHost.querySelector('textarea');
      assert.strictEqual(textArea.value, '0');

      component.progress = 1;
      assert.strictEqual(textArea.value, '0');
      await tasksSettled();
      assert.strictEqual(textArea.value, '1');

      await tearDown();
    });

    it('interpolates the xlint:href attr of <use />', async function () {
      const { component, appHost, tearDown } = await createFixture(
        `<svg>
        <circle id="blue" cx="5" cy="5" r="40" stroke="blue"/>
        <circle id="red" cx="5" cy="5" r="40" stroke="red"/>
        <use href="#\${progress > 0 ? 'blue' : 'red'}" x="10" fill="blue" />
      `,
        class App {
          public progress = 0;
        },
        [SVGAnalyzer],
      ).started;

      const textArea = appHost.querySelector('use');
      assert.strictEqual(textArea.getAttribute('href'), '#red');

      component.progress = 1;
      assert.strictEqual(textArea.getAttribute('href'), '#red');
      await tasksSettled();
      assert.strictEqual(textArea.getAttribute('href'), '#blue');

      await tearDown();
    });

    it('updates binding when array is part of an interpolation', async function () {
      const { component, assertAttr } = createFixture('<div data-id="${ids}">', class {
        ids = [1, 2];
      });

      assertAttr('div', 'data-id', '1,2');
      component.ids.push(3);
      await tasksSettled();
      assertAttr('div', 'data-id', '1,2,3');
    });
  });
});
